Skip to content

test: compile each Tailwind render afresh, and fail on a compile error - #477

Draft
YevheniiKotyrlo wants to merge 8 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/tailwind-harness-cache-key
Draft

YevheniiKotyrlo wants to merge 8 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/tailwind-harness-cache-key

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Problem

src/__tests__/vendor/tailwind/_tailwind.tsx compiles every render through @tailwindcss/postcss with from: __dirname and base: Date.now().toString(). The plugin keeps a compiler per from and base (getContextFromCache) and reuses it until a file it read changes, unless no file is at from (L182–L196); from is the test directory, which does not change. So two renders that read the same millisecond share a compiler, and the second compiles the first one's classes:

jest.spyOn(Date, "now").mockReturnValue(0);
await render(<View className="left-0" />); // stylesheet: left-0
await render(<View className="left-1" />); // stylesheet: left-0, and no style

A vendor case can then assert on another case's output. On main, object-cover failed in one of six runs of src/__tests__/vendor/tailwind and passed in the next; a shared compiler gives that symptom, though no run captured the collision itself.

A compile that fails is only logged: the plugin prints the error and returns an empty stylesheet (L334–L352), so the render registers nothing, and a case that expects no style passes.

Solution

render compiles its CSS with compile from @tailwindcss/node, the string form of the compileAst the plugin calls, which keeps no compiler between calls. Each render builds its own classes, and a CSS that fails to compile rejects the render with Tailwind's error. The plugin's cache serves watch mode, and a from that names no file would not rebuild for every render: once one render's CSS uses no Tailwind feature, the plugin keeps that compiler and returns before it looks for the file, so a later render's CSS comes back uncompiled (L158–L160). A CSS that names a path to scan, through @source or a source() root, rejects too, since the harness builds only the classes of @source inline(); @source not scans nothing and compiles. @tailwindcss/node joins the dev dependencies at the range the other Tailwind packages use, locked to the 4.1.13 the plugin already resolves, and @tailwindcss/postcss and postcss leave them: the harness was their only use outside example/, whose own @tailwindcss/postcss brings postcss with it.

Tests

src/__tests__/vendor/tailwind/harness.test.tsx:

  • freezes Date.now and renders left-0, then left-1: each render's stylesheet holds only its own class, and each view gets its own style;
  • imports a missing file and expects the render to reject;
  • renders after a render whose CSS uses no Tailwind feature;
  • names a path to scan, through @source and through a source() root, and expects the render to reject;
  • excludes a path with @source not and expects its classes.

With the harness from main, five of the six cases fail: all but the @source not render.

Verification

Every ci.yml job passes on this head merged into main: lint, typecheck, the tests (1347 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

Run in one process, src/__tests__/vendor/tailwind ends at 388 MB of heap against 1587 MB on main; with four workers its runs take 6.22–8.01 s against 6.45–10.42 s on main, ranges that overlap. The method and every run, both heap passes and the warm-ups included, are in this comment.

No existing issue tracks this. I searched for flaky, tailwind test cache, Date.now and cache bust.

@tailwindcss/postcss reuses one compiler per `from` and `base`, and an
incremental rebuild keeps the first input's `@source inline()` classes.
The harness passed a fixed `from` and `base: Date.now()`, so two renders
that read the same millisecond shared a compiler and the second one
compiled the first one's classes. A per-render counter keys each render
on its own.
The case already checked that each render compiles only its own class; it now
also checks the style each render applies, so it fails on what a vendor case
sees as well as on what the compiler produced.
The plugin reuses a cached compiler until a file it read changes, and
compiles afresh whenever no file is at `from`, so a `from` with no file
behind it compiles each render's own CSS in one cache entry, where a
`base` per render kept a compiler per render. A failed compile reached
only `console.error` and an empty stylesheet; it now fails the render.
`render.debug` takes the harness's options, and `render` no longer
lists `plugin` and `className`, which it never read.
@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Heap and time — main against this PR

Heap. Every file of src/__tests__/vendor/tailwind in one process, two passes a side, the first filling jest's transform cache; jest reads the heap after a forced GC at the end of each file:

node --expose-gc node_modules/jest/bin/jest.js src/__tests__/vendor/tailwind --runInBand --logHeapUsage
after the first file after the last file growth per file, on average
main (eda2b286), first pass 394 MB 1705 MB 69 MB
main (eda2b286), second pass 273 MB 1587 MB 69 MB
this PR, merged into main, first pass 223 MB 501 MB 14 MB
this PR, merged into main, second pass 109 MB 388 MB 14 MB

For scale, main's src/__tests__/native, which compiles no Tailwind, grows by 7 MB a file the same way.

Time. jest src/__tests__/vendor/tailwind --maxWorkers=4, started at high priority on the same sixteen logical processors for both sides, one warm-up run a side (24.43 s on main, 19.93 s here) and five measured runs, alternating:

runs median
main (eda2b286) 6.45, 6.86, 8.52, 8.75, 10.42 s 8.52 s
this PR, merged into main 6.22, 7.47, 6.89, 6.23, 8.01 s 6.89 s

The two sides' ranges overlap, so I claim no difference in time.

Windows 11 Pro 10.0.26300, AMD Ryzen 9 9950X3D, Node 26.8.1, jest 29.7.0; @tailwindcss/node 4.1.13 here, @tailwindcss/postcss 4.1.13 on main.

@YevheniiKotyrlo YevheniiKotyrlo changed the title test: give each Tailwind render a compiler of its own test: compile each Tailwind render afresh, and fail on a compile error Oct 20, 2026
…s no cache

@tailwindcss/postcss keeps a compiler per input for watch mode. With one
input per test file, a render whose CSS uses no Tailwind feature left a
compiler every later render in the file reused, and the console spy that
caught the plugin's logged failures also took any other error and a
caller's own spy. The harness now compiles with @tailwindcss/node's
compile, the call the plugin wraps, which keeps no cache and rejects a
failed compile; it is declared as a devDependency at the version the
lockfile already holds.
… without reassigning it

getClassNames read `.props` of a text child and threw; it now walks the
tree with isValidElement and Children. render builds its input CSS in a
pure helper instead of reassigning its `css` parameter, renderSimple
builds its warnings without mutation, the options are readonly, and the
debug option's doc names the inspector check the code makes.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant